フロントエンドの型安全性を高める!Jotaiを用いたフォーム設計の実践 @TSKaigi Kansai 2024
自己紹介
mrsekut.icon
mrsekut (まる)
設計からデザインまで全般やっている
フロントエンドが得意
好きなプログラミング言語はHaskell, TypeScript
アジェンダ
フィールドに求めるものは何か?
既存ライブラリでの課題
Jotaiで簡易的なフィールドを作る
型で仕様を表現する
Parse, don't validate ~アプリケーションの境界ですべきこと~
フィールドを完成させる
まとめ
/icons/hr.icon
これは字幕です. ~1:00
アジェンダ
フィールドに求めるものは何か?
既存ライブラリでの課題
Jotaiで簡易的なフィールドを作る
型で仕様を表現する
Parse, don't validate ~アプリケーションの境界ですべきこと~
フィールドを完成させる
まとめ
フォームを書いたことはありますか?
それは、どういうフォームですか?
お問い合わせページのフォーム
SNSの投稿ページのフォーム
....
/icons/hr.icon
僕は、以前のプロジェクトでこんなアプリケーションを作りました
カーテンの注文を取るアプリケーション
オーダーメイドのカーテンの注文を取るために使う
実店舗でスタッフが入力する
窓のサイズなどから、サイズを計算し、必要な生地の長さや金額を算出する
https://gyazo.com/bb29b40d38085b9a60b7698f4b353483
カーテンはたくさんの要素がある
https://gyazo.com/1f0d812a9c1c5ae18f75b8324d8aef99
様々な要素を調整して、最終的な生地量が決まる
/icons/hr.icon
言われてみれば当然ですが、窓の高さより、カーテンの高さのほうが数cm大きいんですね
フィールドが多い!
https://gyazo.com/07f79b69e634f4dcdae61c0364532f3c
様々なオプションを選べるようにする
細かく要素を指定できるように、フィールドが多い
1つの商品に対し、30個弱ほどある
クライアントでリアルタイムに計算を行う
値と金額を見ながら細かく調整できる
入力ミスを防ぐための工夫も必要
バリデーションエラーを表示するなど
/icons/hr.icon
アプリのイメージです。フィールドの値を操作すると、カーテンの模式図が動いたり、合計金額がリアルタイムで変化します。
フィールドの具体例: 窓の高さ
https://gyazo.com/3ed9604d67e8910e5a665cb690728075
要件
数値(整数)である
100 ~ 4000の範囲を取る
単位はmm
この例は、この発表内で何度も登場しますmrsekut.icon
フィールドの要件
▢ 入力時にバリデーションされる
▢ フィールド内の値を取り出してリアルタイムに計算できる
▢ 型は厳密に扱われる
▢ メートルで入力できる
/icons/hr.icon
上記をまとめると、このような要件が必要になります。~3:45
アジェンダ
フィールドに求めるものは何か?
既存ライブラリでの課題
Jotaiで簡易的なフィールドを作る
型で仕様を表現する
Parse, don't validate ~アプリケーションの境界ですべきこと~
フィールドを完成させる
まとめ
react-hook-formを使う
React Hooksベースの有名なライブラリ
サクッとフォームを作れて便利
code:react-hook-formの使用例.ts
import { useForm, SubmitHandler } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
// スキーマを定義
type Schema = z.TypeOf<typeof schema>;
const schema = z.object({
windowHeight: z.string().transform(Number),
});
export function WindowHeightField() {
// useFormで呼び出す
const { register, handleSubmit, watch } = useForm<Schema>({
resolver: zodResolver(schema),
});
const onSubmit: SubmitHandler<Schema> = data => console.log(data);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input type="number" {...register('windowHeight')} />
<input type="submit" />
</form>
);
}
/icons/hr.icon
皆さんは、react-hook-formはご存知でしょうか? Zodの部分は少し補足します。
Zodでバリデーションを書ける
TypeScriptは実行時にはただのJavaScriptになる
TypeScriptレベルの型検査が行われない
実行時型検査をするライブラリが沢山ある
code:Zodの使用例.ts
// stringで受け取って、numberに変換するスキーマを定義
const windowHeight = z.string().transform(Number),
// バリデーションするデータ
const height = "100";
// バリデーション実行
try {
windowHeight.parse(height);
console.log("バリデーション成功");
} catch (error) {
console.error("バリデーションエラー:", error.errors);
}
react-hook-formの課題
課題1: 値を持ち回りづらい
課題2: 型の不一致が起きる
/icons/hr.icon
次で一つずつ見ていきます
課題1: 値を持ち回りづらい
https://gyazo.com/07f79b69e634f4dcdae61c0364532f3c
入力した値をクライアントでの計算に使いたい
submitする前に値を取り出せる必要がある
react-hook-formでは、watch()やgetValues()で値を取り出せる
code:ts
const { watch } = useForm<Schema>(...);
const windowHeight = watch('windowHeight');
Componentのツリー構造に依存されるため不便
useContextと同様のイメージ
/icons/hr.icon
多くのフィールドをセクションを分けて表示するようにしています。セクションを横断するフィールドの値同士を計算する必要があります。
ツリー構造を横断する形式でもできなくはないですが、少しいびつになっていまします。UIの都合であるツリー構造と、計算の都合は分けて考えたいところです。
課題2: 型の不一致が起きる
Zodで、stringで受け取って、numberに変換するスキーマを定義
code:ts
const schema = z.object({
windowHeight: z.string().transform(Number)
})
code:ts
const windowHeight = watch('windowHeight');
// ^? const windowHeight: number
console.log(typeof windowHeight); // string
handleSubmitを通過しないとzodが適用されないため
要件を満たせない
要件
✅️ 入力時にバリデーションされる
▢ フィールド内の値を取り出してリアルタイムに計算できる
▢ 型は厳密に扱われる
▢ メートルで入力できる
今回の要件には厳しかった
お問い合わせフォームのような単純なものには向いている
どうしよう??
https://gyazo.com/8dbf2bf7b0b4bbb3fd8bc9197030a470
/icons/hr.icon
~7:30
アジェンダ
フィールドに求めるものは何か?
既存ライブラリでの課題
Jotaiで簡易的なフィールドを作る
型で仕様を表現する
Parse, don't validate ~アプリケーションの境界ですべきこと~
フィールドを完成させる
まとめ
https://gyazo.com/ea5eefb029179fe7f310ce985a35de40
Reactの状態管理ライブラリの一つ
Atomという単位で、グローバルに状態を管理する
https://gyazo.com/43aa96891a34c0670a918608c0afcfba
code:Jotaiの例.ts
import { useAtom } from 'jotai';
import { atom } from 'jotai';
// カウンターの状態を管理するatomを作成
const countAtom = atom(0);
function Counter() {
// countAtomから状態と更新関数を取得
return (
<div>
<h1>Counter: {count}</h1>
<button onClick={() => setCount(prev => prev + 1)}>+</button>
<button onClick={() => setCount(prev => prev - 1)}>-</button>
</div>
);
}
/icons/hr.icon
global state版のuseStateのようなものですね
Jotaiを使ったフィールドの簡単な例
一つのフィールドに対し、一つのAtomを用意する
code:ts
import { atom, useAtom } from 'jotai';
// ① fieldの内容を保持するatomを定義
const windowHeightAtom = atom<number>(0);
const WindowHeightField: React.FC = () => {
// ② hooks経由でstateにアクセスする
return (
<>
<label htmlFor="windowHeight">窓の高さ</label>
<input
id="windowHeight"
type="number"
value={value}
onChange={e => onChange(+e.target.value)} // 雑にNumberに変換
/>
</>
);
};
https://gyazo.com/b471b734b4dd9049305b466ba7baa090
/icons/hr.icon
今の実装では型の変換なども雑です。
値を自由に取り出せるようになった
フィールドの内容をグローバルに管理する恩恵
UI都合のツリー構造の制限を回避できる
現状
▢ 入力時にバリデーションされる
✅️ フィールド内の値を取り出してリアルタイムに計算できる
▢ 型は厳密に扱われる
▢ メートルで入力できる
/icons/hr.icon
バリデーションは先程のサンプルでは端折ったので後退しました。グローバルに管理できるようになったものの、まだ型周りの問題は残っています。
さらに型を厳密にしていこう
/icons/hr.icon
~9:30
アジェンダ
フィールドに求めるものは何か?
既存ライブラリでの課題
Jotaiで簡易的なフィールドを作る
型で仕様を表現する
Parse, don't validate ~アプリケーションの境界ですべきこと~
フィールドを完成させる
まとめ
少し話は変わって...
単位をどうするか問題
今回のアプリケーションでは複数の「長さの単位」が登場する
e.g. m, cm, mm
UIに表示する単位は、ユーザにとって自然な単位であるべき
https://gyazo.com/da97581cc6af7eeb97ed99d1eff1cdd4
一方で、計算する際には単位を揃えないといけない
e.g. m * mm では計算が狂う
どうするか?
①プログラム内で統一する
②プログラム内で統一せず、計算時に逐一変換する
e.g. 引数はmとmmで、その関数内で変換してから計算
③モジュールごとに変える
/icons/hr.icon
どれが良いでしょうか。
プログラム内では全てmmに統一する
関数の定義時や計算を書く時に、考えることが減る
「この値の単位はどっちだっけ」を気にしなくて済む
mmを要求していることを型で明示したい
「この関数はMMを要求している」ということを型で示したい
こうではなく
code:ts
function calc(a: number, b: number): number;
こうしたい
code:ts
function calc(a: MM, b: MM): MM;
何が嬉しい?
コードを読んで意味がわかる
/villagepump/who1.icon「この計算式はミリメートルを前提しているんだな」
呼び出し時に単位まちがいを防げる
うっかりnumberやCMを渡してしまうことがない
code:例.ts
const a: MM = ..
const b: M = ..
calc(a, b)
// ^^ type error !!
TypeScript は Structural type system
同じ構造だと区別されない
code:ts
type MM = number;
type M = number;
const mm: MM = 1;
const m: M = mm; // not type error
どうするか?
/icons/hr.icon
本当はMMとMを区別したいのに例のコードではできていません
Branded Types
型にuniqueなタグを仕込むことで、別の構造とみなす
実行時型検査するライブラリに付属していることも多い
/icons/hr.icon
このBranded Typesを使って、単位型を定義していきましょう
単位型を定義する
zod.brand()を使って定義する
constructorも用意する
code:Unit.ts
import * as z from 'zod';
// mm
export type MM = z.infer<typeof MM>;
export const MM = z.number().brand<'MM'>(); // ここ
export const mkMM = (mm: number): MM => MM.parse(mm);
// m
export type M = z.infer<typeof M>;
export const M = z.number().brand<'M'>(); // ここ
export const mkM = (m: number): M => M.parse(m);
// 相互に変換する関数も用意しておくと便利
export const m2mm = (m: M): MM => mkMM(m * 1000);
export const mm2m = (mm: MM): M => mkM(mm / 1000);
MM型を使って書けるようになった
before
code:ts
function calc(a: number, b: number): number;
after
code:ts
function calc(a: MM, b: MM): MM;
/icons/hr.icon
~14:00
要件表に一つ追加した
▢ 入力時にバリデーションされる
✅️ フィールド内の値を取り出してリアルタイムに計算できる
▢ 型は厳密に扱われる
▢ メートルで入力できる
▢ ミリメートルで取得できる ← /icons/new1.icon
更に型に意味を込めたい
「単位」だけでなく、「要件を満たしている」という意味も込めたい
HaskellのNew Typeのイメージmrsekut.icon
今のコードの課題
code:ts
function calc(windowHeight: MM, windowWidth: MM): MM;
MMだとわかるが、要件を満たしているかわからない
窓の高さの要件: 「100 ~ 4000の範囲を取る」
もしかしたら、100より小さい値かもしれない
場合によっては、関数ごとにバリデーションが必要になる
WindowHeight型を定義する
code:ts
import * as z from 'zod';
type WindowHeight = z.TypeOf<typeof WindowHeight>;
const WindowHeight = z.number().int().min(100).max(4000).pipe(MM);
窓の高さの要件 (再掲)
数値(整数)である
100 ~ 4000の範囲を取る
単位はmm
内部のロジックはこう書ける
code:ts
function calc(height: WindowHeight, width: WindowWidth): MM;
/icons/hr.icon
これがどう嬉しいのでしょうか?
仕様を満たしていることを型で明示できる
ただの型エイリアスとは全く情報量が異なることがわかるでしょうか?
code:ts
// ❌️ type WindowHeight = number;
type WindowHeight = z.TypeOf<typeof WindowHeight>;
const WindowHeight = z.number().int().min(100).max(4000).pipe(MM);
ある値がWindowHeight型で型付けできるということは、
その値は「窓の高さの要件」を満たしている、ということを意味する
バリデーションを通過済み、というのを型レベルでわかる
この関数は、要件を満たした値が来ることを前提できる
code:ts
function calc(height: WindowHeight, width: WindowWidth): MM;
関数ごとのチェックも、テストも省略できる
WindowHeight型の嬉しさまとめ
コードを読んで意味がわかる
/villagepump/who1.icon「この計算式はミリメートルを前提しているんだな」
呼び出し時に単位まちがいを防げる
うっかりnumberやCMを渡してしまうことがない
仕様を満たしていることが型レベルで分かる
バリデーションを通過しているという情報が型に載る
/icons/hr.icon
~17:00
アジェンダ
フィールドに求めるものは何か?
既存ライブラリでの課題
Jotaiで簡易的なフィールドを作る
型で仕様を表現する
Parse, don't validate ~アプリケーションの境界ですべきこと~
フィールドを完成させる
まとめ
フォームの話に戻そう
ここまでで、WindowHeightという厳格な型を定義できた
では、アプリケーションのどこでWindowHeight型を構築すべきだろうか?
ユーザはフォームにて値を入力する
フォームで入力される値は、常にstringになってしまう
code:ts
<input
type="number"
value={value}
onChange={e => onChange(e.target.value)}
// ↑ string型...
/>
フォームでvalidateしよう!
フォームでvalidateしよう!
...というのは割と当たり前とされている
https://gyazo.com/d368ab079bb6b2a71a6a68afa6159ed8
しかし、これだけでは不十分mrsekut.icon
構造の変換もフォームにて行う
https://gyazo.com/084d616a68b6cef7a466fb13848fabb7
Parse, don't validate
2016年にAlexis King氏によって書かれたもの
Haskell界隈でよく知られている
上記の話を「Parse, don't validate」というフレーズで説明している
Validate
値が正しいかどうかをチェックする(だけ)
ただチェックするだけなので、値にその情報が載らない
Parse
構造的でないデータを、構造的なデータに変換する
検証されたことが、情報として残る
フォームだけでなくプログラム全体でそうする
https://gyazo.com/ee26e27c90d5a622c4ca794fc0a86274
/icons/hr.icon
さっきのfieldの図を拡大していったイメージです。ちなみに、この雑なGIFはFigmaで作りました。
「境界」で仕様を満たした型に変換する
https://gyazo.com/f1d79ac4815379e03697905528072cf3
例えば、サーバからのレスポンスなども同じ
/icons/hr.icon
今回の発表はフォームを具体例に話していますが、他の入力も同じです。
全てのプログラムで同じ
例えばバックエンドならこんな感じ
https://gyazo.com/67e7030915a535cb3230087d6c002c29
/icons/hr.icon
今回の発表はクライアントを例に話していますが、バックエンドなど他のプログラムでも同じです。
できるだけ境界で変換する
https://gyazo.com/e034cfdccbe5caad4b26eed7de3b8f95
境界で信頼できるデータ構造に変換しよう
プログラム内に、「引数の型が厳格である関数」を増やそう
code:引数の型が厳格である関数の例(ts)
function calc(height: WindowHeight, width: WindowWidth): MM;
code:厳格でない例(ts)
function calc(height: number, width: number): MM;
型安全で、堅牢で、プログラムを書ける範囲を広げよう
/icons/hr.icon
~21:15
アジェンダ
フィールドに求めるものは何か?
既存ライブラリでの課題
Jotaiで簡易的なフィールドを作る
型で仕様を表現する
Parse, don't validate ~アプリケーションの境界ですべきこと~
フィールドを完成させる
まとめ
Jotaiで作ったフィールドにparseする仕組みを追加する
/icons/hr.icon
最後に、上記をまとめて、fieldで実現していきましょう。あと少しです。
今の状況
Jotaiでの単純なフィールドの実装の再掲
code:ts
export const windowHeightAtom = atom<number>(0);
export const WindowHeightField: React.FC = () => {
return (
<>
<label htmlFor="windowHeight">height</label>
<input
id="windowHeight"
value={value}
onChange={e => onChange(+e.target.value)}
/>
</>
);
};
要件表
▢ 入力時にバリデーションされる
✅️ フィールド内の値を取り出してリアルタイムに計算できる
▢ 型は厳密に扱われる
▢ メートルで入力できる
▢ ミリメートルで取得できる
/icons/hr.icon
チェック欄を埋めていきましょう。
フィールドでparseする
WindowHeightの例だと、
メートルを文字列で受け取り、
フィールド上でWindowHeight型に変換
例
"1"と入力すると、
WindowHeight型の1000が得られるようにする
https://gyazo.com/b8ef5287e930c66ce3f12953dea6f0c1
/icons/hr.icon
こういうこともJotaiなら自在にできます。
Atomは2つ必要
https://gyazo.com/2657dbdcbde05a571f10bd89a811a023
外部用
フィールドに表示されている値を表す
バリデーションエラーとなる値も許容する
内部用
厳密に構造化された内部用の値を表す
/icons/hr.icon
これを実現するためには2つの状態を管理する必要があります。
2つのAtomを同期する
相互に変換しつつ、同期させる
https://gyazo.com/2aa18a82d61f48caab1d7d720d664328
2つのAtomを同期する薄いライブラリを作った (JSR) https://gyazo.com/4765be1173f1403e18f0d951cf39192d
上記4つを表すinterfaceがあれば良い
A: atom
B: atom
f: A→Bの変換関数
g: A←Bの変換関数
syncAtoms()で同期済みのAtomを作る
code:ts
import { syncAtoms } from '@mrsekut/jotai-sync'
atom1,
atom2,
f,
g,
)
syncedAtom1を更新すると、syncedAtom2も更新される
2つのAtomを同期させる
外部と内部用のAtomを同期させる
code:ts
import { atom } from 'jotai';
import { ng, ok, syncAtoms } from '@mrsekut/jotai-sync';
// 2つのatomを定義
const _windowHeightAtom = atom<WindowHeight>(mkMM(1000));
const _fieldAtom = atom('1');
// 2つのatomを同期させる
_windowHeightAtom,
_fieldAtom,
// 内部→外部
mm => ok(mm2m(mm).toString()),
// 外部→内部
r => {
// parseする
const parsed = IntFromString.pipe(M)
.transform(m2mm)
.pipe(WindowHeight)
.safeParse(r);
if (!parsed.success) return ng(parsed.error.issues0?.message ?? null); return ok(parsed.data);
},
);
/icons/hr.icon
そのまま書くと煩雑になりますが、実際はutilityを作ってもう少し簡易に書いています。import文を一部端折っています。全体のコードはスライドの最後に書いています。
フィールドに適用する
code:ts
export const RailLengthField: React.FC = () => {
const value = useAtomValue(windowHeightAtom);
const error = useAtomValue(errorAtom);
return (
<>
<div>内部の値: {value} mm</div>
<input value={field} onChange={e => onChange(e.target.value)} />
{error != null && <div>{error}</div>}
</>
);
};
https://gyazo.com/fc52829e081df1aee7bb1a7f2296d6cb
要件を満たした🎉
✅️ 入力時にバリデーションされる
✅️ フィールド内の値を取り出してリアルタイムに計算できる
✅️ 型は厳密に扱われる
✅️ メートルで入力できる
✅️ ミリメートルで取得できる
/icons/hr.icon
~25:00
まとめ
WindowHeight型を定義した
Branded TypesによるMMなどの単位の表現
Zodを用いて要件も型に含めた
Parse, don't validate
検証だけではなく、値の構造化もしよう
これを境界で行う
Jotaiを利用したフィールドの設計をした
UIの都合に左右されない状態管理
フィールドを通過するタイミングでparseする
/icons/hr.icon
今回お話した考え方は、フォーム以外にもプログラム全般で応用できるはずです。堅牢なプログラムを実現していきましょう
宣伝
https://gyazo.com/391f64253d6354d2d189f66d5c83348f
東大阪の花園近くにあるベンチャー企業
croccha
ハンドメイド特化のSNS
ウェブ版
TypeScript, Next.js
モバイルアプリ版
TypeScript, React Native, Expo
SNSの開発に興味のある方は是非お声がけください!
/icons/hr.icon
~26:30
応用例
単位の話以外も同様
e.g.
外部: JST
内部: UTC
etc.
コード全体
code:ts
import { atom, useAtom, useAtomValue } from 'jotai';
import * as z from 'zod';
import { ng, ok, syncAtoms } from '@mrsekut/jotai-sync';
/** Unit ====================== */
// mm
type MM = z.infer<typeof MM>;
const MM = z.number().brand<'MM'>();
const mkMM = (mm: number): MM => MM.parse(mm);
// m
type M = z.infer<typeof M>;
const M = z.number().brand<'M'>();
const mkM = (m: number): M => M.parse(m);
// 相互に変換する関数も用意しておくと便利
const m2mm = (m: M): MM => mkMM(m * 1000);
const mm2m = (mm: MM): M => mkM(mm / 1000);
/** Utils ====================== */
export const IntFromString = z
.string()
.regex(/^+-?\d*\.?\d+$/, { message: '数値を入力してください' }) .transform(Number);
/** WindowHeight ====================== */
type WindowHeight = z.TypeOf<typeof WindowHeight>;
const WindowHeight = z.number().int().min(100).max(4000).pipe(MM);
/** Atoms ====================== */
const _windowHeightAtom = atom<WindowHeight>(mkMM(1000));
const _fieldAtom = atom('1');
_windowHeightAtom,
_fieldAtom,
mm => ok(mm2m(mm).toString()),
r => {
const parsed = IntFromString.pipe(M)
.transform(m2mm)
.pipe(WindowHeight)
.safeParse(r);
if (!parsed.success) return ng(parsed.error.issues0?.message ?? null); return ok(parsed.data);
},
);
/** Field ====================== */
export const RailLengthField: React.FC = () => {
const value = useAtomValue(windowHeightAtom);
const error = useAtomValue(errorAtom);
return (
<>
<div>内部の値: {value} mm</div>
<input value={field} onChange={e => onChange(e.target.value)} />
{error != null && <div>{error}</div>}
</>
);
};